iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 25 篇

Day 25|失效的防護:明明加了 @Exclude(),為什麼 API 還是洩漏敏感資料?

  • 分享至 

  • xImage
  •  

在開發 NestJS API 時,我們常需要替回應加上防護機制,確保不會不小心曝光資料庫裡的敏感欄位。假設我們剛完成了一支取得個人檔案的 API,測試時前端順利拿到了使用者資訊與文章數量。

但仔細一看 Response,裡面卻夾帶了一個絕對不該出現的欄位:

{
  "id": 1,
  "email": "tony@example.com",
  "passwordHash": "$2b$10$demo-password-hash",
  "postCount": 2
}

這已經不是單純的格式問題了,即便密碼經過雜湊處理,它依舊是絕對不能外流的敏感資訊,一旦被包含在 API 的回應中,就等同於嚴重的資料外洩。

奇怪的是,我們明明在 Response DTO 替 passwordHash 加了 @Exclude(),Controller 也啟用了 ClassSerializerInterceptor,為什麼防護機制完全沒有生效?

今天就來帶大家拆解 @Exclude() 失效背後的原因!

問題怎麼發生?

為了重現這個問題,我們先透過 TypeOrmModule 註冊 User、Post,以及對應的 Repository:

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: ':memory:',
      entities: [User, Post],
      synchronize: true,
    }),
    TypeOrmModule.forFeature([User, Post]),
  ],
  controllers: [UsersController],
  providers: [UsersService],
})
export class AppModule {}

接著透過 OnModuleInit 在啟動時注入測試用的使用者與文章資料:

@Injectable()
export class UsersService implements OnModuleInit {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
    @InjectRepository(Post)
    private readonly postsRepository: Repository<Post>,
  ) {}

  async onModuleInit(): Promise<void> {
    await this.usersRepository.insert([
      {
        id: 1,
        email: 'tony@example.com',
        passwordHash: '$2b$10$demo-password-hash',
      },
      {
        id: 2,
        email: 'jennie@example.com',
        passwordHash: '$2b$10$another-demo-password-hash',
      },
    ]);

    await this.postsRepository.insert([
      { id: 1, authorId: 1 },
      { id: 2, authorId: 1 },
      { id: 3, authorId: 2 },
    ]);
  }
}

資料準備好後,我們在 UsersService 中實作查詢與資料組合的邏輯:

export type UserProfileData = Pick<
  User,
  'id' | 'email' | 'passwordHash'
> & {
  postCount: number;
};

async getProfileData(userId: number): Promise<UserProfileData> {
  const user = await this.usersRepository.findOneByOrFail({ id: userId });
  const postCount = await this.postsRepository.countBy({
    authorId: user.id,
  });

  // 透過展開運算子組合資料
  return { ...user, postCount };
}

接著,我們定義了個人檔案的 Response DTO,將敏感欄位標記為「序列化時排除」,並在 Controller 啟用了序列化攔截器,最後直接回傳 Service 的結果:

export class UserProfileResponseDto {
  id: number;
  email: string;
  postCount: number;

  @Exclude({ toPlainOnly: true }) // 標註只在轉為 Plain Object 時排除
  passwordHash: string;
}

@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
  @Get(':id/profile')
  getProfile(
    @Param('id', ParseIntPipe) id: number,
  ): Promise<UserProfileResponseDto> {
    return this.usersService.getProfileData(id);
  }
}

一切就緒,發送請求測試:

GET /users/1/profile

結果如文章一開始所述,API 順利運作,但 @Exclude() 卻失效把 passwordHash 傳給了前端。

根因:ClassSerializerInterceptor 需要明確的目標類別

問題的根源,出在我們組裝與回傳資料的方式。

在 Service 中,我們使用了物件展開運算子 return { ...user, postCount };。這個操作在執行期會建立一個標準的 JavaScript Plain Object(純物件),其 Prototype 只是普通的 Object,而不是 UserProfileResponseDto 的類別實例。

即便 Controller 方法在標頭寫明了回傳型別為 Promise<UserProfileResponseDto>,這也僅是編譯期的 TypeScript 型別檢查,並不會在執行期自動將 Plain Object 轉換為 DTO 類別實例。

當 Controller 處理完請求後,ClassSerializerInterceptor 會攔截回傳值,並呼叫底層 class-transformer 的 instanceToPlain() 來執行序列化。

序列化攔截器的運作流程如下:

https://ithelp.ithome.com.tw/upload/images/20261009/201843062IdzgPmfQI.png

攔截器會根據回傳值在執行期對應的類別,尋找 @Exclude 或 @Expose 等 Metadata 標記。如果回傳的是真正的 DTO 實例(UserProfileResponseDto),序列化器就能找到 DTO 上的 @Exclude() 規則並執行排除。

但由於我們回傳的是 Plain Object,攔截器無從得知這份資料應該套用哪一個 DTO 的規則。既然找不到 UserProfileResponseDto 的過濾規則,所有欄位便會被原樣轉出,導致敏感資料直接洩漏。

所以,真正失效的不是 Decorator,也不是 Interceptor,而是我們在回傳前,缺少了把 Plain Object 轉換為 DTO 實例的動作(plain-to-instance)。

排雷指南:在 Service 明確建立 DTO 實例

要讓序列化防護機制重新發揮作用,關鍵在於必須在執行期讓框架知道目標類別是誰。

解法一:使用 plainToInstance() 明確映射

主動將 Plain Object 轉換為 DTO 實例:

async getProfile(userId: number): Promise<UserProfileResponseDto> {
  const profileData = await this.getProfileData(userId);

  // 明確建立 DTO 實例
  return plainToInstance(UserProfileResponseDto, profileData);
}

解法二:使用 @SerializeOptions({ type })

如果你不想手動呼叫 plainToInstance,也可以透過 @SerializeOptions 指定目標 DTO 類別,告知攔截器將回傳的 Plain Object 統一依據該 DTO 進行序列化:

@Get(':id/profile')
@SerializeOptions({ type: UserProfileResponseDto }) // 指定目標類別
getProfile(
  @Param('id', ParseIntPipe) id: number,
): Promise<UserProfileResponseDto> {
  return this.usersService.getProfileData(id);
}

解法三:改用 @Expose() 實作白名單機制

前面兩種解法已經能讓 @Exclude() 順利運作,但這種「黑名單策略」依然留有一個地雷——你只能排除「有記得寫上去」的欄位,卻防不住「未來新增」的敏感資料。

假設未來 User 實體新增了 resetPasswordToken 欄位,而 Service 依然使用展開運算子組裝資料:

return { ...user, postCount };

如果 Response DTO 事先並未將其加入 @Exclude(),即使啟用了 ClassSerializerInterceptor,該欄位依然會被帶進 API 回應中,造成第二次資料外洩。

如果希望從根本上消除這種風險,更建議改用「白名單策略」:只放行明確標註 @Expose() 的欄位,其餘一律自動剔除。

首先,在 Response DTO 中使用 @Expose() 標記允許輸出的欄位:

import { Expose } from 'class-transformer';

export class UserProfileResponseDto {
  @Expose()
  id: number;

  @Expose()
  email: string;

  @Expose()
  postCount: number;
}

接著,在 Controller 配置 @SerializeOptions 並開啟 excludeExtraneousValues:

@Get(':id/profile')
@SerializeOptions({
  type: UserProfileResponseDto,
  excludeExtraneousValues: true, // 開啟白名單過濾
})
getProfile(
  @Param('id', ParseIntPipe) id: number,
): Promise<UserProfileResponseDto> {
  return this.usersService.getProfileData(id);
}

這項設定的作用如下:

  1. type: UserProfileResponseDto:告知攔截器先將回傳的 Plain Object 轉為該 DTO 實例。
  2. excludeExtraneousValues: true:要求轉換過程中只保留有標註 @Expose() 的屬性。其餘未標註的欄位(包含 passwordHash 以及未來的 resetPasswordToken)都會被自動過濾。

最終送出的 Response Body 將會非常乾淨:

{
  "id": 1,
  "email": "tony@example.com",
  "postCount": 2
}

兩種用法的差異如下:

  • @Exclude()(黑名單機制): 預設放行所有欄位,只封鎖指定的敏感資料。
  • @Expose() + excludeExtraneousValues(白名單機制): 預設封鎖所有欄位,只放行明確宣告的資料。

總結

  1. TypeScript 型別不等於執行期實例:方法宣告回傳 Promise<UserProfileResponseDto> 僅存在於編譯期型別檢查,執行期不會自動將 JavaScript 物件轉換為 DTO 類別實例。
  2. 資料組合會產生 Plain Object:使用 { ...user, postCount } 運算子組裝資料時,產生的只是普通的 JavaScript 物件,會失去所有 DTO 類別上的 Metadata 裝飾器標記。
  3. ClassSerializerInterceptor 依賴執行期類別資訊:序列化攔截器必須透過物件的 Class Metadata 來讀取 @Exclude() 或 @Expose() 設定。若收到的只是 Plain Object,過濾機制便無法生效。
  4. 回傳時須明確綁定目標類別:可由 Service 呼叫 plainToInstance() 主動建立 DTO 實例,或由 Controller 掛載 @SerializeOptions({ type }) 告知攔截器目標 DTO。
  5. 優先採用「白名單機制」防護:搭配 @Expose() 與 excludeExtraneousValues: true,預設封鎖所有欄位,能防止未來資料庫新增敏感欄位時意外洩漏的資安風險。

參考資料


上一篇
Day 24|脫離掌控的回應:為什麼用了 @Res(),Interceptor 的轉換結果不見了?
下一篇
Day 26|消失的全域防線:為什麼加了局部 Filter 後,Global Exception Filter 就不再觸發?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言